Long Short-term Memory PART 1

递归神经网络

这里的RNN指的是时间递归层面的神经网络。它的出现是为了解决传统CNN、AE、RBM等网络在序列预测(sequence prediction)问题中表现不佳的状况。CNN善于从图片、视频中提取多层次的抽象特征,AE和RBM善于发现输入数据中隐藏的结构,但是它们的网络结构决定了其不具备信息记忆的能力,而在序列预测问题中,某一位置的信息往往与之前位置的信息有关,因此需要一个带信息存储功能的网络结构,RNN便是具备信息存储能力的网络结构,如下图所示[2]:

注意这里的递归是时间上的递归,也就是说保持网络结构不变,当前时间步(time step)下的输入前向传播得到的输出信息会参与到后一时间步的计算,网络在$t$时刻的输出代表网络对前$t$个时间步的信息记忆,当它和当前时间步的输入$x_{t+1}$一同参与$t+1$时刻的前向传播时,代表了信息记忆的迭代和更新。可以看到,RNN的网络结构理论上是为了记忆前面所有的输入信息,当我们在$t+1$时刻进行预测输入时,前面$t$个时间步的信息参与了预测的过程。但是实际上RNN在长期信息的记忆能力很差,原因是梯度的消失和爆炸问题。

LSTM

为了解决RNN在长期记忆能力上的短板,从网络结构上进行改进,得到了LSTM模型,RNN和LSTM在结构上的差异如下图[2]所示:

LSTM由基本的记忆单元(memory cell)构成,记忆单元又由四部分组成:单元状态(cell state)、遗忘控制层(forget gate layer)、输入控制层(input gate layer)、输出控制层(output gate layer)。

valilla LSTM

这是最原始的LSTM网络结构:一个包含多个memory cell的LSTM层,接上一个全连阶层产生输出。我们通过求解一个简单序列预测问题来熟悉基本LSTM的用法。

问题

给定一个随机的固定长度的整数输入序列$[ x_1, x_2, \ldots, x_T ]$,我们希望LSTM通过学习能够记住输入序列中固定位置的值$x_i$。比如输入[3,5,7,9],我们训练的LSTM是希望它能够输出第三个位置的元素值,则我们期待的输出结果为7.

思路

上述问题本质上是一个多分类问题,我们期待的结果是输入序列中特定位置的某个值,因此考虑用one-hot编码对输入数据进行处理,输出的也是one-hot编码值。我们采用如下图所示的LSTM网络结构:一个LSTM层接上一个全连接层。

输入序列中的元素代表不同时间步(time step)的输入,输入数据的特征数等于one-hot编码值的长度。全连接层对于每个输入序列只输出一个时间步的向量,这个向量就是近似one-hot值。

步骤

1、准备数据
首先需要随机生成多个固定长度的输入序列,这可以通过randint()函数完成:

1
2
def generate_sequence(length, n_features):
return [randint(0, n_features-1) for _ in range(length)]

其中的length参数决定输入序列的长度,n_features参数决定序列中元素的取值范围$[0,\text{n_features} )$

然后需要对输入序列中的所有元素值进行one-hot编码,编码后的one-hot长度为n_features值,元素值正好对应one-hot中“1”的索引值:

1
2
3
4
5
6
7
def one_hot_encoder(sequence, n_features):
encoding = list()
for value in sequence:
vector = [0 for _ in range(n_features)]
vector[value] = 1
encoding.append(vector)
return array(encoding)

我们还需要一个对one-hot编码值进行解码的函数,用于对全连接层的输出向量进行解码操作:

1
2
def one_hot_decoder(encoded_out):
return [argmax(vector) for vector in encoded_out]

因为全连接层输出的向量是softmax输出的所有可能结果的概率分布,因此解码时不是寻找“1”所在的索引,而是寻找最大值所在的索引。

输入序列的生成和编解码基本完成。不过因为我们用Keras来搭建LSTM模型,而Keras中LSTM层的输入要求是三维的数据:(batch_sizes, time steps, features),分别表示输入的样本数量、每个样本包含的时间步数、每个时间步所包含的特征数,因此需要对输入序列进行维度的转换:

1
2
input_x = encoded_seq.reshape((1, length, n_features))
input_y = encoded_seq[out_index].reshape((1, n_features))

其中时间步参数由序列的长度length指定,特征数由每个元素的one-hot编码长度n_features决定,参数out_index表示我们希望LSTM能够预测的元素位置。最后,通过一个统一的函数来产生直接可用的输入序列数据:

1
2
3
4
5
6
def generate_example(length, n_features, out_index):
sequence = generate_sequence(length, n_features)
encoded = one_hot_encode(sequence, n_features)
X = encoded.reshape((1, length, n_features))
y = encoded[out_index].reshape(1, n_features)
return X, y

2、搭建和编译网络
我们只需要两个网络层:一个包含多个memory cell的LSTM层和全连接层,它们分别通过Keras中的LSTM()和Dense()实现:

1
2
3
model = Sequential()
model.add(LSTM(25, input_shape(length, n_features)))
model.add(Dense(n_features, activation='softmax'))

我们建立了一个包含25个memory cell的LSTM层,它包括length个时间步的输入,每个输入包含n_features个特征,再此基础上,增加一个包含n_features个神经元的全连接层,输出值通过softmax转换为概率分布。搭好基本网络结构之后,还需要对网络进行编译后才能用于训练和测试:

1
2
model.compile(loss='categorical_crossentropy', optimizer='adam', metric='acc')
print(model.summary())

编译时指定损失函数为适用于多分类问题的交叉熵损失(这里如果是二分类问题的话,则应该指定’binary_crossentropy’为损失函数),优化方法选择了Adam算法,指定分类准确率作为模型的衡量标准。通过model.summary()输出查看网络的结构,检查和确认是否符合我们的要求。

3、网络的训练和评估
根据model.summary()输出的网络结构信息,如果确认网络结构符合需求,则可以进行网络的训练了。

1
2
3
for i in range(2000):
x, y = generate_example(length, n_features, out_index)
model.fit(x, y, epochs=1)

我们循环训练模型2000次,每次的训练样本为1,需要注意的是:因为输入序列的顺序信息很重要,因此fit()函数中的参数shuffle不能设置为True。训练好之后,便可以进行模型评估:

1
2
3
4
5
6
7
counter = 0
for i in range(300):
x_test, y_test = generate_example(length, n_features, out_index)
y_predict = model.predict(x_test)
if one_hot_decoder(y_test) == one_hot_decoder(y_predict):
counter += 1
print("accuracy: %f."%((counter / 300)*100.0))

这是模型的训练、评估和测试结果,可以看到该模型成功的学习到了如何去正确的预测:

Stacked LSTM

Stacked LSTM也就是深层LSTM,结构上就是将多个LSTM层连接起来:前一LSTM层的输出输入到连接的后一LSTM层上,根据需要可以将多个LSTM层stack在一起。为什么需要将多个LSTM层连接在一起呢?Alex Graves在它的论文[1]中如是说:

RNNs are inherently deep in time, since their hidden state is a function of all previous hidden states. The question that inspired this paper was whether RNNs could also benefit from depth in space; that is from stacking multiple recurrent hidden layers on top of each other, just as feedforward layers are stacked in conventional deep networks.

因此Stacked LSTM是希望能够发现输入序列中更多更深入的隐含信息,我们可以通过一个回归类预测问题来了解它。

问题

给定一组具特定模式的输入序列,预测后续的多个元素值。这里我们对阻尼正弦函数进行采样,输入前length个元素值,预测紧临的后续output个元素值,阻尼正弦函数的形式为:
$$ f(i | period, decay) = a + b \cdot sin( {2 \pi i \over period} ) \cdot e^{-i \cdot decay} $$
其中a,b表示特定的常数,需要保持不变,参数period表示正弦函数的振荡周期,参数decay表示曲线的衰减系数,函数对应的曲线示意图如下:

我们希望Stacked LSTM能够学习到输入序列隐藏的振荡信息和衰减信息,因此每个输入序列的period值和decay均是随机产生,常数值a和b固定不变(我们不希望模型学习到a和b的变化信息)。

思路

首先是等间距的从随机选择的阻尼正弦曲线中有序采样总共 length + output 个元素值,前length个元素值作为模型的输入序列,后output个元素值作为模型待学习的输出序列。将产生的一组训练数据输入到Stacked LSTM模型中训练。

步骤

1、准备数据
因为这里的问题是一个回归问题,因此从阻尼正弦曲线上采样得到的序列不需要进行one-hot编码,但是作为LSTM层的输入,同样需要进行维度转换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 从period和decay决定的阻尼正弦曲线是采样总共length个元素
def generate_sequence(length, period, decay):
return [0.5 + 0.5 * sin(2 * pi * i / period) * exp(-decay * i) for i in range(length)]
# 生成n_patterns个训练序列
def generate_examples(length, n_patterns, output):
X, y = list(), list()
for _ in range(n_patterns):
p = randint(15, 20)
d = uniform(0.05, 0.1)
sequence = generate_sequence(length + output, p, d)
# 取前length个元素作为输入序列
X.append(sequence[:-output])
# 取后output个元素作为输出序列
y.append(sequence[-output:])
# LSTM输入规范:样本数、时间步数、特征数
X = array(X).reshape(n_patterns, length, 1)
y = array(y).reshape(n_patterns, output)
return X, y

2、搭建和编译网络
我们stack两个LSTM层,再加上一个由output个神经元组成的全连接层,结构示意图如下:

因为默认的LSTM对于多时间步的输入序列只产生一组输出(参见valilla LSTM的网络结构),而我们的网络结构前两层均为LSTM层,因此第二个LSTM层的输入同样必须为带时间步的输入序列,因此要求第一个LSTM层对于其每个时间步同时产生一组输出。为此,需要设置LSTM()函数中的参数return_sequences=True,代码如下:

1
2
3
4
5
6
model = Sequential()
model.add(LSTM(20, return_sequences=True, input_shape=(length, 1)))
model.add(LSTM(20))
model.add(Dense(output))
model.compile(loss='mae', optimizer='adam')
print(model.summary())

第一,因为在一层指定了input_shape,因此后面的网络层不再需要指定输入数据的格式;第二,因为是回归问题,因此最后的全连接层采取默认的”激活函数”:线性函数$f(x) = x$。模型的损失函数选择了平均绝对误差$\text{Average} ( |y - \hat y|) $,同样采取Adam优化方法。

3、训练和测试网络

1
2
3
4
5
6
7
8
# 模型训练
x_train, y_train = generate_examples(length, 10000, output)
history = model.fit(x_train, y_train, batch_size=10, epochs=1)
# 模型评估
x_test, y_test = generate_examples(length, 3000, output)
loss = model.evaluate(x_test, y_test, verbose=0)
print('MAE: %f' % loss)

模型训练时只迭代训练一次(epochs=1),每十个输入序列进行一次网络梯度更新(batch_size = 10). 下图是模型的评估结果和测试结果,从整体MAE看,模型的拟合结果误差很小,从右图来看,预测结果的绝对值误差在$10^{-3}$级别:

CNN-LSTM

CNN的长处在于抽象特征的自动抽取,类似于一个编码器,面向的是空间型数据,比如图片。LSTM的长处在于发现序列中的隐藏信息,面向的是时间序列型数据,比如文本序列、音频序列。当我们处理的数据既包含空间型数据也包含时间型数据时,比如视频数据:它的每一帧是图片,整体是由多幅图片在时间轴上排列而成,则可以考虑是CNN-LSTM来处理,下面通过一个简单的问题来了解CNN-LSTM。

问题

给定类似如下的一组图片,预测图片中黑色方块的移动方向(下图中为向右):

思路

这是一个序列的二分类问题:将每一幅图片看作是对应时间步的输入,最后输出其中黑色方块的移动方向。因此需要用LSTM模型来处理输入数据,又因为原始输入数据是图片,其中关键的信息是黑色方块的位置信息,因此考虑用CNN来提取这一特征,作为图片的编码器,最后隐藏层的输出作为图片的特征信息输入到LSTM对应时间步上。

步骤

1、数据准备
数据准备时最重要的是输入数据的格式必须匹配:Keras中Conv2D()层要求输入数据的格式为(samples, rows, cols, channels),因为我们每一个样本有多个图片帧,需要为每个样本增加一个帧数维,因此对应的输入格式应该是(samples, frame_num, height, width, channels)。

1
2
3
4
# (样本数,图片帧数,图片宽像素数,图片高像素数,颜色通道数)
x = array(input_data_x).reshape(samples, frame_num, height, width, channels)
# (样本数,预测类别)
y = array(input_data_y).reshape(samples, 1)

2、网络搭建

CNN:一个卷积层 + 一个池化层;

卷积层设置2个卷积核,每个卷积核的尺寸为(2,2),步长取默认的(1,1),卷积后的结果通过RELU激活函数。卷积层输出的2个特征图输入到最大池化层,池化的尺寸和步长均为(2,2),最后通过Flatten层将池化层的单条输出数据拉成一维格式,方便输入到后面的LSTM层memory cell中。

1
2
3
4
cnn = Sequential()
cnn.add(Conv2D(2, (2,2), activation='relu'), input_shape=(None,width,height,1))
cnn.add(MaxPooling2D(pool_size=(2, 2)))
cnn.add(Flatten())

LSTM:一个LSTM层 + 一个全连接层

LSTM层设置70个memory cell单元,因为是二分类问题,因此全连接层只需要设置一个sigmoid神经元。

1
2
3
model = Sequential()
model.add(LSTM(70))
model.add(Dense(1, activation='sigmoid'))

连接

搭建好了CNN和LSTM后,最后还需要将CNN的输出输入到LSTM层各个时间步的memory cell上。不能够直接连接 CNN的输出层到LSTM层,因为LSTM层需要的输入数据格式是(batch_sizes, timesteps, input_dimensions),它需要的是带时间步的输入序列,而CNN对于给定的一个图片帧输出相应的特征向量,CNN看不到输入图片帧序列中的”时间步”信息,输出的特征向量之间也不带有”时间步”信息。我们的目标是希望将CNN模型重复的作用到一个样本的多个图片帧上,产生带时间步维度的输出结果,keras中的TimeDistributed Wrapper正是为了解决这个问题:只需要将cnn放进TimeDistributed Wrapper中,对应的网络结构示意图如下:

1
2
3
4
model = Sequential()
model.add(TimeDistributed(cnn))
model.add(LSTM(70))
model.add(Dense(1, activation='sigmoid'))

假设CNN输出的特征向量维度为(cnn_out_dim),不加TimeDistributed Wrapper时,对于输入CNN的一个样本(frame_num, height, width, channels),CNN会输出 frame_num 个维度为(cnn_out_dim)的特征;加上 TimeDistributed Wrapper 后,对于输入CNN的一个样本(frame_num, height, width, channels),输出的特征维度为(frame_num, cnn_out_dim),这正好符合LSTM层中 input_shape 的格式

3、模型训练、评估

1
2
3
4
5
6
7
8
9
10
11
12
13
# 模型训练
# 二分类问题采用binary_crossentropy损失函数
model.compile(loss='binary_crossentropy', optimizer='adam', metrics=['acc'])
print(model.summary())
# 模型训练
X, y = generate_examples(size, 5000)
model.fit(X, y, batch_size=32, epochs=1)
# evaluate model
X, y = generate_examples(size, 100)
loss, acc = model.evaluate(X, y, verbose=0)
print('loss: %f, acc: %f' % (loss, acc*100))

下面是输出结果,可以看到,在训练数据上的分类准确率接近94%,在随机生成的测试数据上分类率100%:

参考文献

  1. Speech Recognition With Deep Recurrent Neural Networks;
  2. A great post for introduction of RNN and LSTM;